今天我們將從「探索頁面」開始做起,我們將使用到兩個於 Flutter 中很常被使用於顯示需滾動內容的 widget,也就是 ListView
與 GridView
。我們先來看看今天的目標:
在開始製作探索的頁面之前,先看看我們昨天在 tab_layout.dart
中的實作內容,顯示四個不同 tab 時,我們僅製作了各自顯示於頂端工具列的文字,卻仍未為每個頁面提供獨立的分頁內容。因此我們先來實作此部分,使得導覽列可以成功導向分頁的頁面。
請先在 screens
資料夾底下建立四個檔案:home_screen.dart
、browse_screen.dart
、search_screen.dart
與 profile_screen.dart
,並先建立為 stateless widget。舉 home_screen.dart
為例:
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context) {
// 我們等等會介紹這是什麼
return const SliverToBoxAdapter(
child: Text('Home Screen')
);
}
}
接著請開啟 tab_layout.dart
的檔案中,我們將依據現有的 tabIndex 回傳正確的頁面,請參考以下函式:
// 根據傳入的 tabIndex 回傳相應的頁面,頁面型態為 widget
Widget getTabScreen(int tabIndex) {
switch (tabIndex) {
case 0:
return const HomeScreen();
case 1:
return const BrowseScreen();
case 2:
return const SearchScreen();
case 3:
return const ProfileScreen();
default:
return const HomeScreen();
}
}
透過呼叫上述的函式,就可以成功導向我們所需要的各個分頁拉~
tabBuilder: (BuildContext context, int index) =>
CupertinoPageScaffold(
child: CustomScrollView(slivers: <Widget>[
CupertinoSliverNavigationBar(
largeTitle: Text(tabTitle[index]),
backgroundColor: CupertinoColors.white),
getTabScreen(index),
]))
當我們所要顯示的內容無法於螢幕上一次性的展示時,就需要透過滾動來使內容可以繼續展示。
在 Flutter 中定義了一系列的滾動式組件來讓我們達成此一操作,包括接下來要介紹的 ListView
、GridView
、Sliver widget
等等。
按照我們的設計圖原先應先介紹 GridView 再介紹 ListView,不過我還是想先講這個,比較好入門 XD
ListView
為用於顯示列表的 widget,也是在實現捲動內容時最常使用的 widget,顯示內容會一個接著一個的於捲動的方向上出現,可水平方向或垂直方向的實現。
在 ListView
的類別定義中,提供了四種建構子(含本身與 3 種命名建構子)
ListView
:為最基本的建構子,通過 children
參數接受一個 List<Widget>
作為子元素,並預設將其按照垂直方向排列。適合用於當子元素數量較少時的場景,因為其子元素於建構列表的同時也會一併建立完成。ListView(
children: <Widget>[
Text('Item 1'),
Text('Item 2'),
// 更多項目...
],
)
2. ListView.builder
:用於動態生成子元素的 ListView
,透過 itemBuilder
建構子元素內容。適合用於建構當子元素數量較大的情境,因為該建構子僅會對實際顯示於螢幕上的內容進行調用。
ListView.builder(
itemCount: 100, // 子元素的總數
itemBuilder: (BuildContext context, int index) {
return ListTile(title: Text('Item $index'));
},
)
ListView.separated
:當子元素間需要分隔時可以使用此建構子,除了透過 itemBuilder
來建構子元素外,也有 separatorBuilder
來建構分隔的樣式。ListView.separated(
itemCount: 100,
separatorBuilder: (BuildContext context, int index) {
return SizedBox(
height: 0.5,
child: Container(color: CupertinoColors.systemGrey)
);
},
itemBuilder: (BuildContext context, int index) {
return ListTile(title: Text('Item $index'));
},
)
ListView.custom
:允許自定義列表形式,接收一個 SliverChildDelegate
來動態的生成子元素。ListView.custom(
childrenDelegate: SliverChildBuilderDelegate(
(BuildContext context, int index) {
return ListTile(title: Text('Item $index'));
},
childCount: 100,
),
)
此單元我們將使用 ListView
來進行練習,請打開 browse_screen.dart
參考以下程式碼:
@override
Widget build(BuildContext context) {
return SliverToBoxAdapter(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start, children: [
const Padding(
padding: EdgeInsets.fromLTRB(16, 16, 16, 0),
child: Text('新聞來源',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.w500)),
),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 16.0, vertical: 6),
child: Text('檢視所有新聞來源',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: CupertinoColors.systemGrey)),
),
// 放置 ListView 的位置
ListView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
children: [
Text("暫時放一個 Text 看看會發生什麼事情"),
]
);
]));
}
上述程式碼我們先使用 Column
來排版我們內部的 widgets,並建構了標題與說明文字。接著使用了 ListView
的建構子,並指定滑動為水平方向,以及與周圍的間距。
當你滿心歡喜想說可以跑了的時候,一執行卻發現整個應用程式卡死了... 為什麼呢?如果你仔細去觀看主控台回報的錯誤訊息會看到一行「Horizontal viewport was given unbounded height.」的文字。
引發原因是我們在此頁面最外層使用了 Column
的排版組件,其組件的高度是根據放置於其中的子組件來決定,但其中的 ListView
的垂直高度若沒有外部來的 constraint
會使得預設為無限長。也就變向導致 Column
的高度沒有極限而產生了unbounded height
的錯誤。
所以解決方式很簡單,就是藉由控制 ListView
可使用的高度使得 Column
能夠得到限制。可以這樣寫:
SizedBox(
height: 200,
ListView(...),
)
藉由 SizedBox
來設定高度為 200,來限制 ListView
最大可使用高度,這也就是我們在前面的篇章中有提到過的 constraint
。如此便能成功的運行囉~
接著讓我們來實作新聞來源的子元素的樣子吧!請將以下的程式碼複製個 4 份至 ListView
的 children
吧:
Padding(
// 每個子元素都間隔右邊 16 單位
padding: const EdgeInsets.fromLTRB(0, 0, 16, 0),
child: Column(
// 子元素分成兩部分,圓角灰底的方形將用於顯示圖片 與 新聞來源的文字
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 100,
height: 100,
decoration: const BoxDecoration(
color: CupertinoColors.systemGrey4,
borderRadius: BorderRadius.all(Radius.circular(10)),
),
),
const Padding(
padding: EdgeInsets.symmetric(vertical: 6),
child: Text('新聞來源',
style: TextStyle(
fontSize: 16, color: CupertinoColors.black)),
),
],
),
),
驗收成果
太棒了,我們成功的做出了可水平滑動的新聞來源列表了!接下來就等套用資料來顯示資訊拉。
介紹完 ListView
後,接下來就換 GridView
拉。是用於顯示網格佈局的 widget,適合用於顯示多項目的網格,並且也同樣可用於水平或垂直方向的捲動內容。
GridView
同樣的在類別定義中,也提供了多種建構子:
GridView
:建構網格列表,可調整網格間的間距、每行可有幾個網格GridView(
// gridDelegate 是用於建構網格中子項目排版的方法
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2, // 每行 2 固定兩個網格
mainAxisSpacing: 8, // grid 預設方向也為 vertical,因此若無指定方向則表示 vertical 項目的間距
crossAxisSpacing: 8, // 與上方參數相反,表示 horizontal 項目的間距
),
children: [ ... ]
)
GridView.count
:與上方的建構子相似,但其建構子實作了 gridDelegate
的參數內容,因此更方便使用。GridView.count(
crossAxisCount: 2,
mainAxisSpacing: 8,
crossAxisSpacing: 8,
children: [ ... ]
)
GridView.extent
:可以指定每個子元素的最大寬度。內部也同樣實作了 gridDelegate
的參數內容GridView.extent(
maxCrossAxisExtent: 150, // 每個子元素最大的寬度為 150
children: [ ... ]
)
GridView.builder
:用於動態生成子元素的 GridView
,透過 itemBuilder
建構子元素內容。適合用於建構當子元素數量較大的情境,因為該建構子僅會對實際顯示於螢幕上的內容進行調用。GridView.builder(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2, // 每行 2 固定兩個網格
mainAxisSpacing: 8, // grid 預設方向也為 vertical,因此若無指定方向則表示 vertical 項目的間距
crossAxisSpacing: 8, // 與上方參數相反,表示 horizontal 項目的間距
),
children: [ ... ]
)
在認識完了 GridView
的各種建構子後,我們可以邁入實作階段,將我們設計圖中「新聞來源」區塊上方的「新聞分類」以 GridView
的方式來實現。
// 同樣也要製作「新聞分類」的標題,程式碼的部分就省略拉
GridView.count(
crossAxisCount: 2, // 每行有兩個網格
mainAxisSpacing: 16, // vertical 元素間隔 16 單位
crossAxisSpacing: 16, // horizontal 元素間格 16 單位
childAspectRatio: 16 / 9, // 設定每個網格項目的長寬比為 16 : 9
shrinkWrap: true,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), // GridView 對外的間隔
children: [
// 製作灰底圓角的區塊,可複製多份檢視結果
Container(
decoration: const BoxDecoration(
color: CupertinoColors.systemGrey4,
borderRadius: BorderRadius.all(Radius.circular(10)),
),
)
]
)
值得關注的是我們給定了一個參數 shrinkWrap
為 true
。這個的目的與上方使用 SizedBox
來限制 ListView
的顯示區域相似。
當 shrinkWrap
參數為:
false
(預設) - 滾動區域會佔據整個父容器的可用空間,適合用於無限捲動的情境true
- 滾動區域會根據內容實際大小來調整自身大小,適合用於限定高度之內容若未指定 shrinkWrap
這個參數成 true
,則會套用預設 false
,也就是會無視 children
中放置的內容,盡可能的使用最大高度空間來顯示內容,就會遇到前面所說的導致 column
的 unbound
錯誤。
讓我們先來看看目前的結果:
看出來其他奇怪的地方了嗎?當我在網格的區域內滑動時會只有針對網格來滑動而非整個頁面,如果要讓整個頁面滑動則必須在網格區域外操作才能觸發。
這時候我們再加一個參數在 GridView.count
中,用以標明 GridView
不可被滾動,要滾動的是父容器,也就是整個頁面。
physics: const NeverScrollableScrollPhysics()
再試一次,現在整個頁面都可以進行滑動拉!讚讚~
Sliver 有別於一般的佈局元件,可自定義滾動的效果,像是可伸縮的定端工具列、動態列表,這些效果可透過不同類型的 sliver widget 來呈現。
我們在前面撰寫應用程式外框時即有用到此概念,我們為了達成 iOS 頂部工具列的動畫而使用了 CupertinoSliverNavigationBar
此一 widget,該 widget 被包在一個 CustomScrollView
的參數 slivers
中即是表示放置於其中的子元素為可滾動的內容,我們才能達成最終我們所要的結果。
Sliver 照字面翻譯的意思就是「使...變為薄片」,也就是將要顯示的元素切成一個一個薄片,僅有當該元素需要被顯示時才進行渲染及佈局。可以有效的節省渲染畫面的壓力從而增加效能。
所以我們目前在 browse_screen.dart
最外層的 SliverToBoxAdapter
就是其中一個 sliver widget,其目的是用於將普通的 widget 包成 sliver
的工具,使的我們可以將普通 widget 放置於 CustomScrollView
中。
今天我們介紹了 Flutter 的數個可滾動組件,包括了:
ListView
:用於顯示列表的元件,可根據顯示的為靜態或動態內容來選擇對應的建構子GridView
:用於顯示網格的元件,與 ListView
的用法很類似,不過額外需指定每個 row
可顯示的網格數量及網格間的間距Sliver widget
:可自定義滾動元件的顯示內容,並可有效的增進效能。不過其涵蓋的範圍很廣,幾乎每種 RederBox 皆有其相對應的 Sliver 版本元件,有興趣可至此網站來拜讀。此外我們也完成了當前「探索頁面」於設計稿上的樣式,不過目前看起來還缺很多東西,包括有哪些新聞來源、新聞分類?總不可能完全靠寫死的資料,因此我們明天打算來開始串 API 來動態的取得這些資訊,明天再接再厲拉!
今天的參考程式碼:https://github.com/ChungHanLin/micro_news_tutorial/tree/day15/micro_news_app